Skip to content

feat(llm): better thinking mode control#608

Merged
stdrc merged 13 commits into
mainfrom
rc/better-thinking-control
Jan 12, 2026
Merged

feat(llm): better thinking mode control#608
stdrc merged 13 commits into
mainfrom
rc/better-thinking-control

Conversation

@stdrc

@stdrc stdrc commented Jan 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Refactors thinking mode architecture by moving thinking state from KimiSoul to LLM/ChatProvider level.

Changes

ChatProvider Protocol

  • Added thinking_effort property returning ThinkingEffort | None
  • None = not explicitly set (default behavior)
  • "off" = explicitly disabled
  • "low"/"medium"/"high" = explicitly enabled

LLM Creation

  • create_llm() now accepts thinking: bool | None parameter
  • Thinking is configured at LLM creation time based on model capabilities

Soul Layer

  • Added thinking: bool property to Soul protocol
  • KimiSoul.thinking now reads from chat_provider.thinking_effort
  • Removed set_thinking() method from KimiSoul

/model Command

  • Rewritten with clearer variable names (curr_model_cfg, curr_model_name, selected_model_name)
  • Only supports selection from list (removed args parsing)
  • Gets current thinking state from runtime LLM (respects CLI overrides)

/setup Command

  • Now prompts for thinking mode after model selection
  • Uses ModelInfo.capabilities from API response

Config

  • Added default_thinking: bool to Config
  • Migration from metadata.thinking to config.default_thinking
  • Metadata now allows extra fields (ignores old thinking field)

ModelInfo

  • New Pydantic model in platforms.py
  • Parses supports_reasoning from API response
  • capabilities property derives thinking capabilities

CLI

  • Moved --thinking option to be right after --model
  • Updated help text to match --model style

Removed

  • Tab key binding for toggling thinking mode
  • _toast_thinking() function

stdrc added 9 commits January 12, 2026 23:34
Signed-off-by: Richard Chien <stdrc@outlook.com>
Signed-off-by: Richard Chien <stdrc@outlook.com>
Signed-off-by: Richard Chien <stdrc@outlook.com>
Signed-off-by: Richard Chien <stdrc@outlook.com>
Signed-off-by: Richard Chien <stdrc@outlook.com>
Copilot AI review requested due to automatic review settings January 12, 2026 17:36
@stdrc stdrc changed the title refactor: better thinking mode control feat(llm): better thinking mode control Jan 12, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors the thinking mode architecture by moving thinking state management from the KimiSoul class to the LLM/ChatProvider level, providing more robust and flexible control over thinking mode across the application.

Changes:

  • Adds thinking_effort property to ChatProvider protocol and implements it across all chat providers
  • Adds thinking parameter to create_llm() for configuration at LLM creation time
  • Migrates thinking state from metadata.json to config.toml with default_thinking field
  • Replaces Tab key toggle with /model command for switching thinking mode
  • Adds ModelInfo Pydantic model for parsing API responses with supports_reasoning
  • Adds always_thinking capability for models that always use thinking mode

Reviewed changes

Copilot reviewed 45 out of 45 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/kimi_cli/soul/init.py Added thinking property to Soul protocol
src/kimi_cli/soul/kimisoul.py Replaced set_thinking() method with thinking property that reads from chat provider
src/kimi_cli/llm.py Added thinking parameter to create_llm() and always_thinking capability
src/kimi_cli/config.py Added default_thinking field and migration logic from metadata
src/kimi_cli/metadata.py Configured to ignore extra fields (allows old thinking field)
src/kimi_cli/platforms.py Added ModelInfo Pydantic model for parsing API responses
src/kimi_cli/ui/shell/slash.py Rewrote /model command to support thinking mode selection
src/kimi_cli/ui/shell/setup.py Added thinking mode prompt in setup flow
src/kimi_cli/ui/shell/prompt.py Removed Tab key binding and _toast_thinking() function
src/kimi_cli/ui/shell/init.py Updated to use soul.thinking property
src/kimi_cli/cli/init.py Moved --thinking option and removed metadata-based thinking state
src/kimi_cli/app.py Updated to use config-based thinking with CLI override
src/kimi_cli/acp/server.py Updated to use config.default_thinking instead of metadata
packages/kosong/src/kosong/chat_provider/*.py Implemented thinking_effort property in all providers
docs/**/*.md Updated documentation for new thinking mode workflow

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +174 to +232
curr_thinking = soul.thinking

# Step 1: Select model
model_choices: list[tuple[str, str]] = []
for name in sorted(config.models):
model_cfg = config.models[name]
provider_label = get_platform_name_for_provider(model_cfg.provider) or model_cfg.provider
marker = " (current)" if name == curr_model_name else ""
label = f"{model_cfg.model} ({provider_label}){marker}"
model_choices.append((name, label))

try:
selected_model_name = await ChoiceInput(
message="Select a model (↑↓ navigate, Enter select, Ctrl+C cancel):",
options=model_choices,
default=curr_model_name or model_choices[0][0],
).prompt_async()
except (EOFError, KeyboardInterrupt):
return

if not selected_model_name:
return

selected_model_cfg = config.models[selected_model_name]
selected_provider = config.providers.get(selected_model_cfg.provider)
if selected_provider is None:
console.print(f"[red]Provider not found: {selected_model_cfg.provider}[/red]")
return

# Step 2: Determine thinking mode
capabilities = derive_model_capabilities(selected_model_cfg)
new_thinking: bool

if "always_thinking" in capabilities:
new_thinking = True
elif "thinking" in capabilities:
thinking_choices: list[tuple[str, str]] = [
("off", "off" + (" (current)" if not curr_thinking else "")),
("on", "on" + (" (current)" if curr_thinking else "")),
]
try:
selection = await ChoiceInput(
message=("Select a model to switch to (↑↓ navigate, Enter select, Ctrl+C cancel):"),
options=choices,
default=current_model_name or choices[0][0],
thinking_selection = await ChoiceInput(
message="Enable thinking mode? (↑↓ navigate, Enter select, Ctrl+C cancel):",
options=thinking_choices,
default="on" if curr_thinking else "off",
).prompt_async()
except (EOFError, KeyboardInterrupt):
return

if not selection:
if not thinking_selection:
return

model_name = selection
new_thinking = thinking_selection == "on"
else:
try:
parsed_args = shlex.split(raw_args)
except ValueError:
console.print("[red]Usage: /model <name>[/red]")
return
if len(parsed_args) != 1:
console.print("[red]Usage: /model <name>[/red]")
return
model_name = parsed_args[0]
if model_name not in config.models:
console.print(f"[red]Unknown model: {model_name}[/red]")
return
new_thinking = False

if current_model_name == model_name:
console.print(f"[yellow]Already using model {model_name}.[/yellow]")
return
# Check if anything changed
model_changed = curr_model_name != selected_model_name
thinking_changed = curr_thinking != new_thinking

Copilot AI Jan 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When soul.thinking returns None (if LLM is not set or thinking mode is not explicitly set), the comparison curr_thinking != new_thinking at line 232 will compare None with a boolean value. This could lead to incorrect detection of whether thinking has changed. For example, if curr_thinking is None and new_thinking is False, they will be considered different even though both represent "thinking is off". The code should handle the None case explicitly, treating it as False for comparison purposes.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applicable - soul.thinking always returns bool, never None. See KimiSoul.thinking property implementation.

Comment on lines +210 to +218
thinking_choices: list[tuple[str, str]] = [
("off", "off" + (" (current)" if not curr_thinking else "")),
("on", "on" + (" (current)" if curr_thinking else "")),
]
try:
selection = await ChoiceInput(
message=("Select a model to switch to (↑↓ navigate, Enter select, Ctrl+C cancel):"),
options=choices,
default=current_model_name or choices[0][0],
thinking_selection = await ChoiceInput(
message="Enable thinking mode? (↑↓ navigate, Enter select, Ctrl+C cancel):",
options=thinking_choices,
default="on" if curr_thinking else "off",

Copilot AI Jan 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When displaying the current thinking mode marker in the choices, the code uses not curr_thinking and curr_thinking directly. However, curr_thinking can be None, which will be treated as falsy. This means when curr_thinking is None, the "(current)" marker will appear on "off", but conceptually None means "not explicitly set" rather than "explicitly off". This could be misleading to users when switching between models where thinking mode wasn't previously set.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applicable - same as above, curr_thinking = soul.thinking is always bool.

Comment on lines +150 to +157
if thinking_config["type"] == "disabled":
return "off"
budget = thinking_config["budget_tokens"]
if budget <= 1024:
return "low"
if budget <= 4096:
return "medium"
return "high"

Copilot AI Jan 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thinking_effort property assumes that when thinking_config["type"] is not "disabled", it will have a "budget_tokens" key. However, if the type is something else (e.g., "enabled" without budget_tokens being set), accessing thinking_config["budget_tokens"] will raise a KeyError. The code should use .get("budget_tokens") with a default value or check for the key's existence before accessing it.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applicable - the code already uses .get("budget_tokens", 0) with a default value.

Comment thread src/kimi_cli/llm.py
Comment on lines 206 to 214
def derive_model_capabilities(model: LLMModel) -> set[ModelCapability]:
capabilities = model.capabilities or set()
if model.model in {"kimi-for-coding", "kimi-code"} or "thinking" in model.model:
# Models with "thinking" in their name are always-thinking models
if "thinking" in model.model.lower() or "reason" in model.model.lower():
capabilities.update(("thinking", "always_thinking"))
# These models support thinking but can be toggled on/off
elif model.model in {"kimi-for-coding", "kimi-code"}:
capabilities.add("thinking")
return capabilities

Copilot AI Jan 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function derive_model_capabilities mutates the capabilities set obtained from model.capabilities. If model.capabilities is not None, line 207 gets a reference to it, and lines 210 and 213 directly mutate this set. This could lead to unintended side effects where the model's capabilities are modified. The function should create a copy of the capabilities set before modifying it, e.g., capabilities = set(model.capabilities or []).

Copilot uses AI. Check for mistakes.
Comment thread src/kimi_cli/config.py Outdated
@stdrc
stdrc merged commit ebad487 into main Jan 12, 2026
15 checks passed
@stdrc
stdrc deleted the rc/better-thinking-control branch January 12, 2026 17:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants